a tool for shared writing and social publishing
1"use client";
2import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3import {
4 useState,
5 useCallback,
6 useRef,
7 useLayoutEffect,
8 useEffect,
9} from "react";
10import { createPortal } from "react-dom";
11import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
12import * as Popover from "@radix-ui/react-popover";
13import { EditorState, TextSelection, Plugin } from "prosemirror-state";
14import { EditorView } from "prosemirror-view";
15import { Schema, MarkSpec, Mark } from "prosemirror-model";
16import { baseKeymap } from "prosemirror-commands";
17import { keymap } from "prosemirror-keymap";
18import { history, undo, redo } from "prosemirror-history";
19import { inputRules, InputRule } from "prosemirror-inputrules";
20import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
21import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox";
22
23// Schema with only links, mentions, and hashtags marks
24const bskyPostSchema = new Schema({
25 nodes: {
26 doc: { content: "block+" },
27 paragraph: {
28 content: "inline*",
29 group: "block",
30 parseDOM: [{ tag: "p" }],
31 toDOM: () => ["p", 0] as const,
32 },
33 text: {
34 group: "inline",
35 },
36 },
37 marks: {
38 link: {
39 attrs: {
40 href: {},
41 },
42 inclusive: false,
43 parseDOM: [
44 {
45 tag: "a[href]",
46 getAttrs(dom: HTMLElement) {
47 return {
48 href: dom.getAttribute("href"),
49 };
50 },
51 },
52 ],
53 toDOM(node) {
54 let { href } = node.attrs;
55 return ["a", { href, target: "_blank", class: "text-accent" }, 0];
56 },
57 } as MarkSpec,
58 mention: {
59 attrs: {
60 did: {},
61 },
62 inclusive: false,
63 parseDOM: [
64 {
65 tag: "span.mention",
66 getAttrs(dom: HTMLElement) {
67 return {
68 did: dom.getAttribute("data-did"),
69 };
70 },
71 },
72 ],
73 toDOM(node) {
74 let { did } = node.attrs;
75 return [
76 "span",
77 {
78 class: "mention text-accent-contrast",
79 "data-did": did,
80 },
81 0,
82 ];
83 },
84 } as MarkSpec,
85 hashtag: {
86 attrs: {
87 tag: {},
88 },
89 inclusive: false,
90 parseDOM: [
91 {
92 tag: "span.hashtag",
93 getAttrs(dom: HTMLElement) {
94 return {
95 tag: dom.getAttribute("data-tag"),
96 };
97 },
98 },
99 ],
100 toDOM(node) {
101 let { tag } = node.attrs;
102 return [
103 "span",
104 {
105 class: "hashtag text-accent-contrast",
106 "data-tag": tag,
107 },
108 0,
109 ];
110 },
111 } as MarkSpec,
112 },
113});
114
115// Input rule to automatically apply hashtag mark
116function createHashtagInputRule() {
117 return new InputRule(/#([\w]+)\s$/, (state, match, start, end) => {
118 const [fullMatch, tag] = match;
119 const tr = state.tr;
120
121 // Replace the matched text (including space) with just the hashtag and space
122 tr.replaceWith(start, end, [
123 state.schema.text("#" + tag),
124 state.schema.text(" "),
125 ]);
126
127 // Apply hashtag mark to # and tag text only (not the space)
128 tr.addMark(
129 start,
130 start + tag.length + 1,
131 bskyPostSchema.marks.hashtag.create({ tag }),
132 );
133
134 return tr;
135 });
136}
137
138export function BlueskyPostEditorProsemirror(props: {
139 editorStateRef: React.MutableRefObject<EditorState | null>;
140 initialContent?: string;
141 onCharCountChange?: (count: number) => void;
142}) {
143 const mountRef = useRef<HTMLDivElement | null>(null);
144 const viewRef = useRef<EditorView | null>(null);
145 const [editorState, setEditorState] = useState<EditorState | null>(null);
146 const [mentionState, setMentionState] = useState<{
147 active: boolean;
148 range: { from: number; to: number } | null;
149 selectedMention: { handle: string; did: string } | null;
150 }>({ active: false, range: null, selectedMention: null });
151
152 const handleMentionSelect = useCallback(
153 (
154 mention: { handle: string; did: string },
155 range: { from: number; to: number },
156 ) => {
157 if (!viewRef.current) return;
158 const view = viewRef.current;
159 const { from, to } = range;
160 const tr = view.state.tr;
161
162 // Delete the query text (keep the @)
163 tr.delete(from + 1, to);
164
165 // Insert the mention text after the @
166 const mentionText = mention.handle;
167 tr.insertText(mentionText, from + 1);
168
169 // Apply mention mark to @ and handle
170 tr.addMark(
171 from,
172 from + 1 + mentionText.length,
173 bskyPostSchema.marks.mention.create({ did: mention.did }),
174 );
175
176 // Add a space after the mention
177 tr.insertText(" ", from + 1 + mentionText.length);
178
179 view.dispatch(tr);
180 view.focus();
181 },
182 [],
183 );
184
185 const mentionStateRef = useRef(mentionState);
186 mentionStateRef.current = mentionState;
187
188 useLayoutEffect(() => {
189 if (!mountRef.current) return;
190
191 const initialState = EditorState.create({
192 schema: bskyPostSchema,
193 doc: props.initialContent
194 ? bskyPostSchema.nodeFromJSON({
195 type: "doc",
196 content: props.initialContent.split("\n").map((line) => ({
197 type: "paragraph",
198 content: line ? [{ type: "text", text: line }] : undefined,
199 })),
200 })
201 : undefined,
202 plugins: [
203 inputRules({ rules: [createHashtagInputRule()] }),
204 keymap({
205 "Mod-z": undo,
206 "Mod-y": redo,
207 "Shift-Mod-z": redo,
208 Enter: (state, dispatch) => {
209 // Check if mention autocomplete is active
210 const currentMentionState = mentionStateRef.current;
211 if (
212 currentMentionState.active &&
213 currentMentionState.selectedMention &&
214 currentMentionState.range
215 ) {
216 handleMentionSelect(
217 currentMentionState.selectedMention,
218 currentMentionState.range,
219 );
220 return true;
221 }
222 // Otherwise let the default Enter behavior happen (new paragraph)
223 return false;
224 },
225 }),
226 keymap(baseKeymap),
227 autolink({
228 type: bskyPostSchema.marks.link,
229 shouldAutoLink: () => true,
230 defaultProtocol: "https",
231 }),
232 history(),
233 ],
234 });
235
236 setEditorState(initialState);
237 props.editorStateRef.current = initialState;
238
239 const view = new EditorView(
240 { mount: mountRef.current },
241 {
242 state: initialState,
243 dispatchTransaction(tr) {
244 const newState = view.state.apply(tr);
245 view.updateState(newState);
246 setEditorState(newState);
247 props.editorStateRef.current = newState;
248 props.onCharCountChange?.(newState.doc.textContent.length);
249 },
250 },
251 );
252
253 viewRef.current = view;
254
255 return () => {
256 view.destroy();
257 viewRef.current = null;
258 };
259 }, [handleMentionSelect]);
260
261 return (
262 <div className="relative w-full h-full group">
263 {editorState && (
264 <MentionAutocomplete
265 editorState={editorState}
266 view={viewRef}
267 onSelect={handleMentionSelect}
268 onMentionStateChange={(active, range, selectedMention) => {
269 setMentionState({ active, range, selectedMention });
270 }}
271 />
272 )}
273 {editorState?.doc.textContent.length === 0 && (
274 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
275 Write a post to share your writing!
276 </div>
277 )}
278 <div
279 ref={mountRef}
280 className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm"
281 style={{
282 wordWrap: "break-word",
283 overflowWrap: "break-word",
284 }}
285 />
286 <IOSBS view={viewRef} />
287 </div>
288 );
289}
290
291function MentionAutocomplete(props: {
292 editorState: EditorState;
293 view: React.RefObject<EditorView | null>;
294 onSelect: (
295 mention: { handle: string; did: string },
296 range: { from: number; to: number },
297 ) => void;
298 onMentionStateChange: (
299 active: boolean,
300 range: { from: number; to: number } | null,
301 selectedMention: { handle: string; did: string } | null,
302 ) => void;
303}) {
304 const [mentionQuery, setMentionQuery] = useState<string | null>(null);
305 const [mentionRange, setMentionRange] = useState<{
306 from: number;
307 to: number;
308 } | null>(null);
309 const [mentionCoords, setMentionCoords] = useState<{
310 top: number;
311 left: number;
312 } | null>(null);
313
314 const { suggestionIndex, setSuggestionIndex, suggestions } =
315 useMentionSuggestions(mentionQuery);
316
317 // Check for mention pattern whenever editor state changes
318 useEffect(() => {
319 const { $from } = props.editorState.selection;
320 const textBefore = $from.parent.textBetween(
321 Math.max(0, $from.parentOffset - 50),
322 $from.parentOffset,
323 null,
324 "\ufffc",
325 );
326
327 // Look for @ followed by word characters before cursor
328 const match = textBefore.match(/@([\w.]*)$/);
329
330 if (match && props.view.current) {
331 const queryBefore = match[1];
332 const from = $from.pos - queryBefore.length - 1;
333
334 // Get text after cursor to find the rest of the handle
335 const textAfter = $from.parent.textBetween(
336 $from.parentOffset,
337 Math.min($from.parent.content.size, $from.parentOffset + 50),
338 null,
339 "\ufffc",
340 );
341
342 // Match word characters after cursor until space or end
343 const afterMatch = textAfter.match(/^([\w.]*)/);
344 const queryAfter = afterMatch ? afterMatch[1] : "";
345
346 // Combine the full handle
347 const query = queryBefore + queryAfter;
348 const to = $from.pos + queryAfter.length;
349
350 setMentionQuery(query);
351 setMentionRange({ from, to });
352
353 // Get coordinates for the autocomplete popup
354 const coords = props.view.current.coordsAtPos(from);
355 setMentionCoords({
356 top: coords.bottom + window.scrollY,
357 left: coords.left + window.scrollX,
358 });
359 setSuggestionIndex(0);
360 } else {
361 setMentionQuery(null);
362 setMentionRange(null);
363 setMentionCoords(null);
364 }
365 }, [props.editorState, props.view, setSuggestionIndex]);
366
367 // Update parent's mention state
368 useEffect(() => {
369 const active = mentionQuery !== null && suggestions.length > 0;
370 const selectedMention =
371 active && suggestions[suggestionIndex]
372 ? suggestions[suggestionIndex]
373 : null;
374 props.onMentionStateChange(active, mentionRange, selectedMention);
375 }, [mentionQuery, suggestions, suggestionIndex, mentionRange]);
376
377 // Handle keyboard navigation for arrow keys only
378 useEffect(() => {
379 if (!mentionQuery || !props.view.current) return;
380
381 const handleKeyDown = (e: KeyboardEvent) => {
382 if (suggestions.length === 0) return;
383
384 if (e.key === "ArrowUp") {
385 e.preventDefault();
386 if (suggestionIndex > 0) {
387 setSuggestionIndex((i) => i - 1);
388 }
389 } else if (e.key === "ArrowDown") {
390 e.preventDefault();
391 if (suggestionIndex < suggestions.length - 1) {
392 setSuggestionIndex((i) => i + 1);
393 }
394 }
395 };
396
397 const dom = props.view.current.dom;
398 dom.addEventListener("keydown", handleKeyDown);
399
400 return () => {
401 dom.removeEventListener("keydown", handleKeyDown);
402 };
403 }, [
404 mentionQuery,
405 suggestions,
406 suggestionIndex,
407 props.view,
408 setSuggestionIndex,
409 ]);
410
411 if (!mentionCoords || suggestions.length === 0) return null;
412
413 // The styles in this component should match the Menu styles in components/Layout.tsx
414 return (
415 <Popover.Root open>
416 {createPortal(
417 <Popover.Anchor
418 style={{
419 top: mentionCoords.top,
420 left: mentionCoords.left,
421 position: "absolute",
422 }}
423 />,
424 document.body,
425 )}
426 <Popover.Portal>
427 <Popover.Content
428 side="bottom"
429 align="start"
430 sideOffset={4}
431 collisionPadding={20}
432 onOpenAutoFocus={(e) => e.preventDefault()}
433 className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`}
434 >
435 <ul className="list-none p-0 text-sm">
436 {suggestions.map((result, index) => {
437 return (
438 <div
439 className={`
440 MenuItem
441 font-bold z-10 py-1 px-3
442 text-left text-secondary
443 flex gap-2
444 ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""}
445 hover:bg-border-light hover:text-secondary
446 outline-none
447 `}
448 key={result.did}
449 onClick={() => {
450 if (mentionRange) {
451 props.onSelect(result, mentionRange);
452 setMentionQuery(null);
453 setMentionRange(null);
454 setMentionCoords(null);
455 }
456 }}
457 onMouseDown={(e) => e.preventDefault()}
458 >
459 @{result.handle}
460 </div>
461 );
462 })}
463 </ul>
464 </Popover.Content>
465 </Popover.Portal>
466 </Popover.Root>
467 );
468}
469
470function useMentionSuggestions(query: string | null) {
471 const [suggestionIndex, setSuggestionIndex] = useState(0);
472 const [suggestions, setSuggestions] = useState<
473 { handle: string; did: string }[]
474 >([]);
475
476 useDebouncedEffect(
477 async () => {
478 if (!query) {
479 setSuggestions([]);
480 return;
481 }
482
483 const agent = new Agent("https://public.api.bsky.app");
484 const result = await agent.searchActorsTypeahead({
485 q: query,
486 limit: 8,
487 });
488 setSuggestions(
489 result.data.actors.map((actor) => ({
490 handle: actor.handle,
491 did: actor.did,
492 })),
493 );
494 },
495 300,
496 [query],
497 );
498
499 useEffect(() => {
500 if (suggestionIndex > suggestions.length - 1) {
501 setSuggestionIndex(Math.max(0, suggestions.length - 1));
502 }
503 }, [suggestionIndex, suggestions.length]);
504
505 return {
506 suggestions,
507 suggestionIndex,
508 setSuggestionIndex,
509 };
510}
511
512/**
513 * Converts a ProseMirror editor state to Bluesky post facets.
514 * Extracts mentions, links, and hashtags from the editor state and returns them
515 * as an array of Bluesky richtext facets with proper byte positions.
516 */
517export function editorStateToFacetedText(
518 state: EditorState,
519): [string, AppBskyRichtextFacet.Main[]] {
520 let fullText = "";
521 let facets: AppBskyRichtextFacet.Main[] = [];
522 let byteOffset = 0;
523
524 // Iterate through each paragraph in the document
525 state.doc.forEach((paragraph) => {
526 if (paragraph.type.name !== "paragraph") return;
527
528 // Process each inline node in the paragraph
529 paragraph.forEach((node) => {
530 if (node.isText) {
531 const text = node.text || "";
532 const unicodeString = new UnicodeString(text);
533
534 // If this text node has marks, create a facet
535 if (node.marks.length > 0) {
536 const facet: AppBskyRichtextFacet.Main = {
537 index: {
538 byteStart: byteOffset,
539 byteEnd: byteOffset + unicodeString.length,
540 },
541 features: marksToFeatures(node.marks),
542 };
543
544 if (facet.features.length > 0) {
545 facets.push(facet);
546 }
547 }
548
549 fullText += text;
550 byteOffset += unicodeString.length;
551 }
552 });
553
554 // Add newline between paragraphs (except after the last one)
555 if (paragraph !== state.doc.lastChild) {
556 const newline = "\n";
557 const unicodeNewline = new UnicodeString(newline);
558 fullText += newline;
559 byteOffset += unicodeNewline.length;
560 }
561 });
562
563 return [fullText, facets];
564}
565
566function marksToFeatures(marks: readonly Mark[]) {
567 const features: AppBskyRichtextFacet.Main["features"] = [];
568
569 for (const mark of marks) {
570 switch (mark.type.name) {
571 case "mention": {
572 features.push({
573 $type: "app.bsky.richtext.facet#mention",
574 did: mark.attrs.did,
575 });
576 break;
577 }
578 case "hashtag": {
579 features.push({
580 $type: "app.bsky.richtext.facet#tag",
581 tag: mark.attrs.tag,
582 });
583 break;
584 }
585 case "link":
586 features.push({
587 $type: "app.bsky.richtext.facet#link",
588 uri: mark.attrs.href as string,
589 });
590 break;
591 }
592 }
593
594 return features;
595}